Fork me on GitHub

Writing a JavaScript framework - Sandboxed Code Evaluation

转载自网络,原文链接:https://blog.risingstack.com/writing-a-javascript-framework-sandboxed-code-evaluation/

This is the third chapter of the Writing a JavaScript framework series. In this chapter, I am going to explain the different ways of evaluating code in the browser and the issues they cause. I will also introduce a method, which relies on some new or lesser known JavaScript features.

The series is about an open-source client-side framework, called NX. During the series, I explain the main difficulties I had to overcome while writing the framework. If you are interested in NX please visit the home page.

The series includes the following chapters:

  1. Project structuring
  2. Execution timing
  3. Sandboxed code evaluation (current chapter)
  4. Data binding introduction
  5. Data Binding with ES6 Proxies
  6. Custom elements
  7. Client-side routing

The evil eval

The eval() function evaluates JavaScript code represented as a string.

A common solution for code evaluation is the eval() function. Code evaluated by eval() has access to closures and the global scope, which leads to a security issue called code injection and makes eval() one of the most notorious features of JavaScript.

Despite being frowned upon, eval() is very useful in some situations. Most modern front-end frameworks require its functionality but don’t dare to use it because of the issue mentioned above. As a result, many alternative solutions emerged for evaluating strings in a sandbox instead of the global scope. The sandbox prevents the code from accessing secure data. Usually it is a simple JavaScript object, which replaces the global object for the evaluated code.

The common way

The most common eval() alternative is complete re-implementation - a two-step process, which consists of parsing and interpreting the passed string. First the parser creates an abstract syntax tree, then the interpreter walks the tree and interprets it as code inside a sandbox.

This is a widely used solution, but it is arguably too heavy for such a simple thing. Rewriting everything from scratch instead of patching eval() introduces a lot of bug opportunities and it requires frequent modifications to follow the latest language updates as well.

An alternative way

NX tries to avoid re-implementing native code. Evaluation is handled by a tiny library that uses some new or lesser known JavaScript features.

This section will progressively introduce these features and use them to explain the nx-compile code evaluation library. The library has a function called compileCode(), which works like below.

1
2
3
4
5
6
7
8
9
10
11
const code = compileCode('return num1 + num2')

// this logs 17 to the console
console.log(code({num1: 10, num2: 7}))

const globalNum = 12
const otherCode = compileCode('return globalNum')

// global scope access is prevented
// this logs undefined to the console
console.log(otherCode({num1: 2, num2: 3}))

By the end of this article, we will implement the compileCode() function in less than 20 lines.

new Function()

The Function constructor creates a new Function object. In JavaScript, every function is actually a Function object.

The Function constructor is an alternative to eval(). new Function(...args, 'funcBody') evaluates the passed 'funcBody' string as code and returns a new function that executes that code. It differs from eval() in two major ways.

  • It evaluates the passed code just once. Calling the returned function will run the code without re-evaluating it.
  • It doesn’t have access to local closure variables, however, it can still access the global scope.
1
2
3
function compileCode (src) {
return new Function(src)
}

new Function() is a better alternative to eval() for our use case. It has superior performance and security, but global scope access still has to be prevented to make it viable.

The ‘with’ keyword

The with statement extends the scope chain for a statement.

with is a lesser known keyword in JavaScript. It allows a semi-sandboxed execution. The code inside a with block tries to retrieve variables from the passed sandbox object first, but if it doesn’t find it there, it looks for the variable in the closure and global scope. Closure scope access is prevented by new Function() so we only have to worry about the global scope.

1
2
3
4
function compileCode (src) {
src = 'with (sandbox) {' + src + '}'
return new Function('sandbox', src)
}

with uses the in operator internally. For every variable access inside the block, it evaluates the variable in sandbox condition. If the condition is truthy, it retrieves the variable from the sandbox. Otherwise, it looks for the variable in the global scope. By fooling with to always evaluate variable in sandbox as truthy, we could prevent it from accessing the global scope.

Sandboxed code evaluation: Simple 'with' statement

ES6 proxies

The Proxy object is used to define custom behavior for fundamental operations like property lookup or assignment.

An ES6 Proxy wraps an object and defines trap functions, which may intercept fundamental operations on that object. Trap functions are invoked when an operation occurs. By wrapping the sandbox object in a Proxy and defining a has trap, we can overwrite the default behavior of the in operator.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function compileCode (src) {
src = 'with (sandbox) {' + src + '}'
const code = new Function('sandbox', src)

return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {has})
return code(sandboxProxy)
}
}

// this trap intercepts 'in' operations on sandboxProxy
function has (target, key) {
return true
}

The above code fools the with block. variable in sandbox will always evaluate to true because the has trap always returns true. The code inside the with block will never try to access the global object.

Sandboxed code evaluation: 'with' statement and proxies

Symbol.unscopables

A symbol is a unique and immutable data type and may be used as an identifier for object properties.

Symbol.unscopables is a well-known symbol. A well-known symbol is a built-in JavaScript Symbol, which represents internal language behavior. Well-known symbols can be used to add or overwrite iteration or primitive conversion behavior for example.

The Symbol.unscopables well-known symbol is used to specify an object value of whose own and inherited property names are excluded from the ‘with’ environment bindings.

Symbol.unscopables defines the unscopable properties of an object. Unscopable properties are never retrieved from the sandbox object in with statements, instead they are retrieved straight from the closure or global scope. Symbol.unscopables is a very rarely used feature. You can read about the reason it was introduced on this page.

Sandboxed code evaluation: 'with' statement and proxies. A security issue.

We can fix above issue by defining a get trap on the sandbox Proxy, which intercepts Symbol.unscopables retrieval and always returns undefined. This will fool the with block into thinking that our sandbox object has no unscopable properties.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function compileCode (src) {
src = 'with (sandbox) {' + src + '}'
const code = new Function('sandbox', src)

return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {has, get})
return code(sandboxProxy)
}
}

function has (target, key) {
return true
}

function get (target, key) {
if (key === Symbol.unscopables) return undefined
return target[key]
}

Sandboxed code evaluation: 'with' statement and proxies. Has and get traps.

WeakMaps for caching

The code is now secure, but its performance can be still upgraded, since it creates a new Proxy on every invocation of the returned function. This can be prevented by caching and using the same Proxy for every function call with the same sandbox object.

A proxy belongs to a sandbox object, so we could simply add the proxy to the sandbox object as a property. However, this would expose our implementation details to the public, and it wouldn’t work in case of an immutable sandbox object frozen with Object.freeze(). Using a WeakMap is a better alternative in this case.

The WeakMap object is a collection of key/value pairs in which the keys are weakly referenced. The keys must be objects, and the values can be arbitrary values.

A WeakMap can be used to attach data to an object without directly extending it with properties. We can use WeakMaps to indirectly add the cached Proxies to the sandbox objects.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const sandboxProxies = new WeakMap()

function compileCode (src) {
src = 'with (sandbox) {' + src + '}'
const code = new Function('sandbox', src)

return function (sandbox) {
if (!sandboxProxies.has(sandbox)) {
const sandboxProxy = new Proxy(sandbox, {has, get})
sandboxProxies.set(sandbox, sandboxProxy)
}
return code(sandboxProxies.get(sandbox))
}
}

function has (target, key) {
return true
}

function get (target, key) {
if (key === Symbol.unscopables) return undefined
return target[key]
}

This way only one Proxy will be created per sandbox object.

Final notes

The above compileCode() example is a working sandboxed code evaluator in just 19 lines of code. If you would like to see the full source code of the nx-compile library, you can find it in this Github repository.

Apart from explaining code evaluation, the goal of this chapter was to show how new ES6 features can be used to alter the existing ones, instead of re-inventing them. I tried to demonstrate the full power of Proxies and Symbols through the examples.